Skip to content

SEC-07: SSO/OIDC integration with optional MFA policy#813

Merged
Chris0Jeky merged 21 commits intomainfrom
feature/sso-oidc-mfa-policy
Apr 12, 2026
Merged

SEC-07: SSO/OIDC integration with optional MFA policy#813
Chris0Jeky merged 21 commits intomainfrom
feature/sso-oidc-mfa-policy

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Add pluggable OIDC provider support (Microsoft Entra ID, Google, generic OIDC) with config-gated registration
  • Add TOTP-based MFA with setup, confirmation, verification, and disable flows
  • Add MFA challenge modal for sensitive action gates (password change, account deletion)
  • OIDC login buttons on LoginView (config-gated, only visible when providers configured)
  • MFA setup UI component for user settings
  • Comprehensive security tests covering claim mapping, email collision protection, cross-provider isolation, TOTP validation, and failure modes
  • ADR-0028 documenting design decisions

Closes #82

Key Design Decisions

  • No auto-linking by email: OIDC logins with matching emails create new users to prevent account takeover (consistent with existing GitHub OAuth posture)
  • OIDC disabled by default: Zero configuration means zero OIDC endpoints are active
  • MFA always optional: Never forced without explicit admin policy (MfaPolicy:RequireMfaForSensitiveActions)
  • Shared authorization code store: Both OIDC and GitHub OAuth reuse the same short-lived code pattern
  • Provider naming: oidc_{ProviderName} prefix isolates identity namespaces across providers

Configuration

{
  "Oidc": {
    "Providers": [
      {
        "Name": "entra",
        "DisplayName": "Microsoft Entra ID",
        "Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
        "ClientId": "your-client-id",
        "ClientSecret": "your-client-secret",
        "Scopes": ["openid", "profile", "email"]
      }
    ]
  },
  "MfaPolicy": {
    "EnableMfaSetup": true,
    "RequireMfaForSensitiveActions": true
  }
}

Files Changed

Domain

  • MfaCredential.cs -- new entity for TOTP credentials
  • User.cs -- added MfaEnabled flag and EnableMfa/DisableMfa methods

Application

  • OidcProviderSettings.cs -- pluggable OIDC provider configuration
  • MfaPolicySettings.cs -- MFA policy configuration
  • MfaService.cs -- TOTP generation, validation, setup, and disable flows
  • MfaDtos.cs / OidcDtos.cs -- request/response DTOs
  • IMfaCredentialRepository.cs -- repository interface
  • IUnitOfWork.cs -- added MfaCredentials property

Infrastructure

  • MfaCredentialConfiguration.cs -- EF Core configuration
  • MfaCredentialRepository.cs -- repository implementation
  • Migration 20260409120000_AddMfaCredentials -- adds MfaCredentials table and User.MfaEnabled column
  • UnitOfWork.cs / DependencyInjection.cs -- DI registration

API

  • MfaController.cs -- MFA endpoints (status, setup, confirm, verify, disable)
  • AuthController.cs -- OIDC login/callback/exchange endpoints, updated providers endpoint
  • AuthenticationRegistration.cs -- OpenIdConnect provider registration
  • SettingsRegistration.cs / Program.cs -- OIDC/MFA settings binding

Frontend

  • auth.ts -- OIDC and MFA types
  • authApi.ts -- OIDC exchange and MFA API client methods
  • LoginView.vue -- OIDC login buttons (config-gated)
  • sessionStore.ts -- OIDC code exchange
  • MfaSetup.vue -- MFA setup component for settings
  • MfaChallengeModal.vue -- MFA challenge modal for protected actions

Docs

  • ADR-0028-sso-oidc-mfa-integration.md -- design decision record

Test plan

  • Backend build succeeds (dotnet build backend/Taskdeck.sln -c Release)
  • All 3,805 backend tests pass (dotnet test backend/Taskdeck.sln -c Release -m:1)
  • Frontend typecheck passes (npm run typecheck)
  • All 1,898 frontend tests pass (npx vitest --run)
  • Manual: verify OIDC login flow with a test provider
  • Manual: verify MFA setup/confirm/verify/disable flow
  • Manual: verify OIDC buttons only appear when providers configured
  • Manual: verify MFA challenge modal appears for sensitive actions

Domain layer for TOTP-based MFA: MfaCredential entity tracks per-user
TOTP secrets with confirmation state and recovery codes. User entity
gains MfaEnabled boolean and EnableMfa/DisableMfa methods.
OidcProviderSettings for pluggable OIDC provider configs, MfaPolicySettings
for optional MFA policy, MfaService with TOTP generation/validation,
IMfaCredentialRepository, and supporting DTOs.
MfaCredentialRepository, EF configuration, migration adding MfaCredentials
table and User.MfaEnabled column, UnitOfWork and DI registration.
MfaController with setup/confirm/verify/disable endpoints, OIDC login/callback
endpoints in AuthController, OpenIdConnect provider registration, settings
binding for OIDC and MFA policy configuration.
…changes

Add MfaCredentials property to FakeUnitOfWork implementations across test
files. Update AuthController instantiation to include OidcSettings parameter.
MfaServiceTests covering setup, confirm, disable, verify, TOTP validation,
base32 encoding, status, and policy checks. OidcSecurityTests covering
provider validation, email collision protection, cross-provider isolation,
username deduplication, inactive user rejection, and config validation.
MfaCredentialTests for domain entity validation. User MFA flag tests.
OIDC login buttons on LoginView (config-gated), MFA types and API
client methods, MfaSetup component for settings, MfaChallengeModal
for protected action verification, sessionStore OIDC code exchange.
Documents design decisions for pluggable OIDC provider support,
TOTP-based MFA, identity mapping strategy, and authorization code flow.
EF Core requires a designer file alongside each migration for the
model snapshot at that migration point.
Copilot AI review requested due to automatic review settings April 9, 2026 18:37
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Security Review

CRITICAL Findings

None identified.

HIGH Findings

1. TOTP secret stored in plaintext in database (HIGH)

  • Location: MfaService.SetupAsync stores the Base32-encoded TOTP secret directly in the MfaCredential.Secret column
  • Risk: If the SQLite database file is compromised, all TOTP secrets are immediately usable to generate valid codes
  • Mitigation: Recovery codes are bcrypt-hashed (good), but the TOTP secret must be stored in plaintext to compute HMAC-SHA1 -- this is an inherent limitation of TOTP. The ADR documents this tradeoff. Encrypting at rest would require a key management system beyond current scope.
  • Status: Accepted risk, documented in ADR-0028. Follow-up: add encryption-at-rest for sensitive columns when key management infrastructure is available.

2. No TOTP replay protection (HIGH)

  • Location: MfaService.ValidateTotp -- same TOTP code can be used multiple times within the validity window
  • Risk: If an attacker observes a valid TOTP code (shoulder surfing, MITM), they can reuse it within the 30-90 second window
  • Mitigation: Rate limiting on MFA endpoints (AuthPerIp) limits brute-force. True replay protection requires tracking used codes (e.g., in a short-lived cache keyed by userId+code+timeStep).
  • Status: Will fix -- adding used-code tracking.

MEDIUM Findings

3. Unused OidcExchangeCodeRequest record (MEDIUM)

  • Location: AuthController.cs -- OidcExchangeCodeRequest is defined but the OIDC exchange endpoint uses ExchangeCodeRequest instead
  • Risk: Dead code, no functional impact
  • Status: Will fix -- remove unused record.

4. MFA setup race condition (MEDIUM)

  • Location: MfaService.SetupAsync -- between DeleteByUserIdAsync and AddAsync, two concurrent requests could create duplicate credentials
  • Risk: The unique index on (UserId) in MfaCredentials prevents duplicate persistence (DB-level guard), so the second request would fail with a DB exception
  • Mitigation: The unique index provides safety. A transaction wrapper would improve UX (proper error message instead of 500).
  • Status: Accepted -- DB constraint provides safety net.

5. Recovery code " " (space) sentinel value (MEDIUM)

  • Location: MfaService.TryUseRecoveryCode sets RecoveryCodes to " " when last code is used
  • Risk: The space string satisfies domain validation but is semantically empty. A bcrypt hash of " " could theoretically match if an attacker tries space as a recovery code.
  • Mitigation: Extremely unlikely (bcrypt hash of " " won't match any of the original recovery code hashes). But the sentinel is inelegant.
  • Status: Will fix -- use a constant like EXHAUSTED instead.

LOW Findings

6. Provider name in error messages could leak configuration (LOW)

  • Location: OIDC login/callback endpoints return the provider display name in error messages
  • Risk: Confirms which OIDC providers are configured to unauthenticated users (the provider list is already public via /auth/providers)
  • Status: Accepted -- provider names are intentionally public.

7. ConcurrentDictionary auth code store does not survive restarts (LOW)

  • Location: AuthController._authCodes -- shared between GitHub and OIDC flows
  • Risk: Any pending OAuth codes are lost on process restart. 60-second window limits impact.
  • Status: Accepted -- documented in ADR-0028.

Verification

  • OIDC redirect URLs validated with Url.IsLocalUrl (prevents open redirect)
  • Email collision creates new user, never auto-links (prevents account takeover)
  • Cross-provider isolation: provider+userId is the unique key, not just userId
  • TOTP uses constant-time comparison (prevents timing attacks)
  • MFA endpoints require [Authorize] (prevents unauthenticated access)
  • Rate limiting on all auth-sensitive endpoints
  • Recovery codes are bcrypt-hashed at rest
  • No secrets logged or exposed in responses (OIDC secrets never leave server)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces pluggable OIDC SSO sign-in (multi-provider, config-gated) and optional TOTP-based MFA (setup + verification + disable), along with supporting persistence, API endpoints, UI components, and ADR documentation.

Changes:

  • Add OIDC provider configuration/registration and new auth endpoints (providers listing, OIDC login/callback/exchange).
  • Add MFA domain model + persistence (EF config + migration) and application/service + API endpoints for status/setup/confirm/verify/disable.
  • Add frontend UI for OIDC sign-in buttons and MFA setup/challenge, plus extensive backend test coverage and ADR-0028.

Reviewed changes

Copilot reviewed 45 out of 46 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
frontend/taskdeck-web/src/views/LoginView.vue Adds config-gated OIDC provider buttons and wiring for provider discovery.
frontend/taskdeck-web/src/types/auth.ts Adds OIDC provider, MFA status/setup/verify types.
frontend/taskdeck-web/src/store/sessionStore.ts Adds OIDC code exchange action to session store.
frontend/taskdeck-web/src/components/MfaSetup.vue New MFA setup/confirm/disable UI component.
frontend/taskdeck-web/src/components/MfaChallengeModal.vue New modal UI to verify MFA for sensitive actions.
frontend/taskdeck-web/src/api/authApi.ts Adds OIDC exchange + MFA API client methods.
docs/decisions/INDEX.md Adds ADR-0028 to decisions index.
docs/decisions/ADR-0028-sso-oidc-mfa-integration.md Documents design decisions for OIDC + optional MFA integration.
backend/tests/Taskdeck.Domain.Tests/Entities/UserTests.cs Tests default and toggling behavior for User.MfaEnabled.
backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs Adds unit tests for the new MfaCredential entity.
backend/tests/Taskdeck.Application.Tests/Services/OidcSecurityTests.cs Adds security tests for external login/OIDC claim mapping and collision protections.
backend/tests/Taskdeck.Application.Tests/Services/MfaServiceTests.cs Adds tests for MFA setup/confirm/verify/disable and helpers (Base32/TOTP).
backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs Updates fake unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs Updates fake unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs Updates fake unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs Updates stub unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs Updates fake unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs Updates fake unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs Updates fake unit-of-work to include MFA repository property.
backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs Updates controller construction to include OidcSettings.
backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs Updates stub unit-of-work to include MFA repository property.
backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs Adds MfaCredentials repository to unit-of-work implementation.
backend/src/Taskdeck.Infrastructure/Repositories/MfaCredentialRepository.cs Implements repository for MFA credentials.
backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs Adds DbSet<MfaCredential>.
backend/src/Taskdeck.Infrastructure/Persistence/Configurations/UserConfiguration.cs Persists User.MfaEnabled with default false.
backend/src/Taskdeck.Infrastructure/Persistence/Configurations/MfaCredentialConfiguration.cs EF Core mapping for MFA credentials, unique per-user.
backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs Updates snapshot for MFA credential table and user flag.
backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.Designer.cs Auto-generated migration designer for MFA table + user column.
backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.cs Adds MfaCredentials table and Users.MfaEnabled column.
backend/src/Taskdeck.Infrastructure/DependencyInjection.cs Registers IMfaCredentialRepository.
backend/src/Taskdeck.Domain/Entities/User.cs Adds MfaEnabled + enable/disable methods.
backend/src/Taskdeck.Domain/Entities/MfaCredential.cs New MFA credential entity (secret + confirmation + recovery codes).
backend/src/Taskdeck.Application/Services/OidcProviderSettings.cs Adds OIDC provider config + configured-provider filtering.
backend/src/Taskdeck.Application/Services/MfaService.cs Implements MFA setup/confirm/verify/disable, TOTP + recovery code logic.
backend/src/Taskdeck.Application/Services/MfaPolicySettings.cs Adds MFA policy settings (setup enablement + sensitive action requirements).
backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs Adds MfaCredentials repository property.
backend/src/Taskdeck.Application/Interfaces/IMfaCredentialRepository.cs New repository interface for MFA credentials.
backend/src/Taskdeck.Application/DTOs/OidcDtos.cs DTO for exposing configured OIDC provider info to frontend.
backend/src/Taskdeck.Application/DTOs/MfaDtos.cs DTOs for MFA setup/status/verify requests.
backend/src/Taskdeck.Api/Taskdeck.Api.csproj Adds OpenIdConnect authentication package reference.
backend/src/Taskdeck.Api/Program.cs Wires OIDC settings into authentication registration.
backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs Binds/registers OIDC + MFA policy settings.
backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs Registers OpenIdConnect providers (config-gated).
backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs Registers MfaService.
backend/src/Taskdeck.Api/Controllers/MfaController.cs Adds authenticated MFA endpoints (status/setup/confirm/verify/disable).
backend/src/Taskdeck.Api/Controllers/AuthController.cs Adds providers endpoint output + OIDC login/callback/exchange endpoints.
Files not reviewed (1)
  • backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.Designer.cs: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +255 to +259
hashedCodes.RemoveAt(i);
credential.SetRecoveryCodes(hashedCodes.Count > 0
? string.Join(",", hashedCodes)
: " "); // Keep non-empty to satisfy domain validation
return true;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the last recovery code is used, this calls credential.SetRecoveryCodes(" "). SetRecoveryCodes rejects whitespace-only values (string.IsNullOrWhiteSpace), so this will throw and turn a successful recovery-code verification into a server error. Store null/empty to represent “no codes left” (and update the domain method accordingly), or add a dedicated method to clear recovery codes safely.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +10
/// <summary>
/// Stores a TOTP-based MFA credential for a user.
/// Each user may have at most one active TOTP credential.
/// The shared secret is stored encrypted at rest by the infrastructure layer.
/// </summary>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MfaCredential’s doc comment claims the shared secret is “stored encrypted at rest by the infrastructure layer”, but in this PR the EF configuration persists Secret as a plain string with no value converter/encryption. Either implement encryption at rest (e.g., EF Core value converter with data protection) or update the documentation to avoid giving a false security guarantee.

Copilot uses AI. Check for mistakes.
Comment on lines 19 to 22
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record ExchangeCodeRequest(string Code);
public record OidcExchangeCodeRequest(string Code, string Provider);

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OidcExchangeCodeRequest is declared but never used (the exchange endpoint accepts ExchangeCodeRequest). This adds dead code and can confuse consumers about the required request shape. Remove it, or switch the endpoint to use it if you intend to require the provider name on exchange.

Copilot uses AI. Check for mistakes.
const route = useRoute()
const session = useSessionStore()

import type { OidcProviderInfo } from '../types/auth'
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import type { OidcProviderInfo } ... appears after executable statements (const router = ...). In <script setup>, imports must come before other statements; as-is this will fail to compile/typecheck. Move the import up with the other imports.

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +113
<p class="td-mfa-setup__step">
1. Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<div class="td-mfa-setup__qr">
<code class="td-mfa-setup__secret">{{ setupResponse.sharedSecret }}</code>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup wizard instructs users to “Scan the QR code below”, but no QR code is rendered and setupResponse.qrCodeUri is never used—only the secret is displayed. Either render a QR code from qrCodeUri or adjust the copy so it matches the UI.

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +132
authBuilder.AddOpenIdConnect(schemeName, provider.DisplayName, options =>
{
options.Authority = provider.Authority;
options.ClientId = provider.ClientId;
options.ClientSecret = provider.ClientSecret;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new OIDC handler relies on a sign-in scheme to persist the external principal between the middleware callback path and the controller callback. This AddOpenIdConnect setup does not set options.SignInScheme, and the app doesn’t register any cookie/default sign-in scheme, so OIDC (and GitHub) remote auth will fail at runtime. Add a dedicated cookie scheme (e.g., "External") and set it as DefaultSignInScheme / options.SignInScheme.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +84
/// <summary>
/// Revokes this MFA credential by clearing the secret and marking as unconfirmed.
/// The entity remains for audit trail purposes.
/// </summary>
public void Revoke()
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revoke() is documented as “clearing the secret”, but the implementation only flips IsConfirmed and leaves Secret / recovery codes intact. Either clear sensitive fields when revoking (and consider making it irreversible), or update the doc comment to match the behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +64
function startOidcLogin(providerName: string) {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'
const redirect = [route.query.redirect].flat()[0]
const returnUrl = redirect
? `/login?redirect=${encodeURIComponent(redirect)}`
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This starts the OIDC login flow, but the page currently treats any returned oauth_code as a GitHub sign-in (exchange endpoint + user-facing messages). That will make OIDC sign-in UX misleading and leaves the OIDC exchange path unused. Consider adding a provider discriminator in the redirect (or switching exchange based on source) and updating status/error text accordingly.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for OIDC-based SSO and TOTP-based Multi-Factor Authentication (MFA). It adds the necessary infrastructure, domain entities, and API endpoints to support these features, including configuration-gated OIDC providers and MFA policy settings. The review identified several areas for improvement, including increasing the entropy of generated recovery codes, removing unused code, refactoring duplicated logic, and addressing inconsistencies in domain validation and entity management.

Comment on lines +332 to +335
// Generate an 8-character alphanumeric recovery code in two groups: XXXX-XXXX
var bytes = RandomNumberGenerator.GetBytes(5);
var hex = Convert.ToHexString(bytes).ToUpperInvariant()[..8];
codes[i] = $"{hex[..4]}-{hex[4..8]}";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The generated recovery codes have relatively low entropy. An 8-character hex string provides 32 bits of entropy (16^8 = 2^32 possibilities). While these are single-use and the hashes are stored, an attacker who can bypass rate limiting could potentially brute-force a code. Consider increasing the entropy by generating longer codes. For example, using 8 bytes from RandomNumberGenerator would produce a 16-character hex string with 64 bits of entropy, which is significantly more secure.

            // Generate a 16-character alphanumeric recovery code in two groups: XXXXXXXX-XXXXXXXX
            var bytes = RandomNumberGenerator.GetBytes(8);
            var hex = Convert.ToHexString(bytes).ToUpperInvariant();
            codes[i] = $"{hex[..8]}-{hex[8..]}";


public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record ExchangeCodeRequest(string Code);
public record OidcExchangeCodeRequest(string Code, string Provider);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This record OidcExchangeCodeRequest appears to be unused. The OidcExchangeCode action method uses ExchangeCodeRequest instead. To avoid confusion and dead code, this record should be removed.

Comment on lines +357 to +369
public IActionResult OidcExchangeCode([FromBody] ExchangeCodeRequest request)
{
if (string.IsNullOrWhiteSpace(request.Code))
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required"));

if (!_authCodes.TryRemove(request.Code, out var entry))
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Invalid or expired code"));

if (DateTimeOffset.UtcNow > entry.Expiry)
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Code has expired"));

return Ok(entry.Result);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method OidcExchangeCode is identical to the existing ExchangeCode method for GitHub. This code duplication can be avoided by extracting the logic into a private helper method that both actions can call. This would improve maintainability and reduce the chance of bugs if one method is updated but the other is not.

For example, you could create a private method:

private IActionResult ExchangeCodeInternal(ExchangeCodeRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Code))
        return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required"));

    if (!_authCodes.TryRemove(request.Code, out var entry))
        return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Invalid or expired code"));

    if (DateTimeOffset.UtcNow > entry.Expiry)
        return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Code has expired"));

    return Ok(entry.Result);
}

Then both ExchangeCode and OidcExchangeCode can be simplified to one-line calls to this helper.

Comment on lines +124 to +126
var callbackPath = !string.IsNullOrWhiteSpace(provider.CallbackPath)
? provider.CallbackPath
: $"/api/auth/oidc/{provider.Name.ToLowerInvariant()}/oauth-redirect";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to construct a default CallbackPath is a bit confusing. The default path /api/auth/oidc/{providerName}/oauth-redirect is not an endpoint handled by a controller and might be difficult for users to discover when configuring their IdP.

Consider removing this custom default and letting the OpenID Connect middleware use its standard default path, which is typically /signin-oidc. This simplifies configuration for the user, as they would only need to register one callback URL for all OIDC providers unless they explicitly override it in the configuration.

hashedCodes.RemoveAt(i);
credential.SetRecoveryCodes(hashedCodes.Count > 0
? string.Join(",", hashedCodes)
: " "); // Keep non-empty to satisfy domain validation
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a single space " " to bypass the domain validation for an empty list of recovery codes is a workaround that can be confusing. It would be cleaner to adjust the domain validation in MfaCredential.SetRecoveryCodes to allow an empty string, signifying that no recovery codes are left. This would make the logic here more straightforward and remove the need for this magic value.

                : string.Empty); // Keep non-empty to satisfy domain validation

Comment on lines +269 to +295
internal static string Base32Encode(byte[] data)
{
var result = new char[(data.Length * 8 + 4) / 5];
var buffer = 0;
var bitsLeft = 0;
var index = 0;

foreach (var b in data)
{
buffer = (buffer << 8) | b;
bitsLeft += 8;

while (bitsLeft >= 5)
{
bitsLeft -= 5;
result[index++] = Base32Chars[(buffer >> bitsLeft) & 0x1F];
}
}

if (bitsLeft > 0)
{
buffer <<= (5 - bitsLeft);
result[index] = Base32Chars[buffer & 0x1F];
}

return new string(result);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Implementing custom encoding/decoding logic like Base32 can be risky. While this implementation appears reasonable, it's generally recommended to use a well-vetted, standard library for such operations to avoid potential subtle bugs and security issues. Consider replacing this custom implementation with a popular NuGet package like Base32 to improve robustness and security.

Comment on lines +84 to +88
public void Revoke()
{
IsConfirmed = false;
Touch();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This Revoke() method appears to be unused. The MfaService.DisableAsync method deletes the MfaCredential entity directly instead of revoking it. Furthermore, the comment here states 'The entity remains for audit trail purposes', which contradicts the deletion behavior in the service.

Please either use this Revoke() method in the service to align with the audit trail goal, or remove this method and its comment to eliminate dead code and confusion.

Add TOTP replay protection using ConcurrentDictionary to track used codes
within the validity window. Remove unused OidcExchangeCodeRequest record.
Replace space sentinel with EXHAUSTED constant for exhausted recovery codes.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea9c57ba19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +256 to +258
credential.SetRecoveryCodes(hashedCodes.Count > 0
? string.Join(",", hashedCodes)
: " "); // Keep non-empty to satisfy domain validation
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid throwing when consuming the last recovery code

When the final recovery code is used, hashedCodes.Count becomes 0 and this branch writes a single space to SetRecoveryCodes. MfaCredential.SetRecoveryCodes rejects whitespace-only values, so this path throws a DomainException instead of returning a normal verification result. In practice, the user's last valid recovery code triggers a 500 from VerifyCodeAsync/MfaController.Verify rather than succeeding cleanly.

Useful? React with 👍 / 👎.

Comment on lines +96 to +97
var result = await _mfaService.VerifyCodeAsync(userId, request.Code);
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return server-verifiable proof from MFA verify flow

This endpoint only returns 204 NoContent after code validation and does not mint/store any challenge proof tied to the session or action, so sensitive operations cannot enforce that MFA was just satisfied. As implemented, a client can still call sensitive APIs directly with only the JWT, meaning RequireMfaForSensitiveActions has no effective server-side enforcement path.

Useful? React with 👍 / 👎.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Security Review Fixes Applied

The following findings from the adversarial review have been addressed in commit e3ba9a1:

HIGH: TOTP replay protection -- Fixed. Added ConcurrentDictionary<string, DateTimeOffset> to track used TOTP codes keyed by userId:code:timeStep. Codes are rejected if already used within the tolerance window. Expired entries are cleaned up lazily on each validation call.

MEDIUM: Unused OidcExchangeCodeRequest record -- Fixed. Removed the dead code.

MEDIUM: Recovery code sentinel value -- Fixed. Replaced " " sentinel with explicit "EXHAUSTED" constant. TryUseRecoveryCode now short-circuits when recovery codes are exhausted.

All 3,805 backend tests continue to pass. Frontend typecheck and 1,898 tests also pass.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Independent Adversarial Review (Round 2) -- Security-Focused

CRITICAL

C1. TOTP shared secret stored in plaintext in database

File: backend/src/Taskdeck.Infrastructure/Persistence/Configurations/MfaCredentialConfiguration.cs
File: backend/src/Taskdeck.Domain/Entities/MfaCredential.cs

The MfaCredential.Secret property stores the Base32-encoded TOTP shared secret as plaintext in SQLite. The entity XML comment claims "The shared secret is stored encrypted at rest by the infrastructure layer" but no value converter, data protection, or encryption is applied in MfaCredentialConfiguration. If the SQLite database file is accessed by an attacker (file read, backup leak, SQL injection elsewhere), every user TOTP secret is immediately compromised, allowing the attacker to generate valid TOTP codes for any account.

Fix: Add an EF Core value converter using IDataProtector or AES-GCM to encrypt the secret before storage. Alternatively, use ASP.NET Core Data Protection with a purpose string for TOTP secrets. At minimum, remove the misleading comment about encryption at rest.


C2. MFA policy for sensitive actions is dead code -- no enforcement exists

File: backend/src/Taskdeck.Application/Services/MfaService.cs (line 186)
Files: AuthController.cs, all other controllers

IsMfaRequiredForSensitiveActionAsync exists in MfaService but is never called from any controller or service. The ChangePassword endpoint, account deletion, and all other sensitive actions proceed without any MFA check, even when RequireMfaForSensitiveActions = true and the user has MFA enabled. The MfaChallengeModal.vue frontend component is also dead code (created but never imported or referenced by any view).

The PR description and ADR claim MFA gates sensitive actions. This is currently false -- the entire MFA enforcement pipeline is unconnected. An attacker with a stolen JWT can change passwords and delete accounts without MFA challenge.

Fix: Wire IsMfaRequiredForSensitiveActionAsync into the change-password and account-deletion endpoints. Either require a verified MFA token in the request body, or implement a short-lived MFA session claim after verification.


HIGH

H1. DisableAsync does not accept recovery codes -- users can be permanently locked out

File: backend/src/Taskdeck.Application/Services/MfaService.cs

DisableAsync only calls ValidateTotp() but does NOT fall through to TryUseRecoveryCode(). If a user loses access to their authenticator app, they cannot disable MFA even with valid recovery codes. This contrasts with VerifyCodeAsync which correctly tries recovery codes as a fallback.

This creates a permanent lockout scenario: user enables MFA, loses phone, has recovery codes, but cannot disable MFA to regain account access. The only recovery path is direct database intervention.

Fix: After ValidateTotp fails in DisableAsync, call TryUseRecoveryCode as a fallback, matching the pattern in VerifyCodeAsync.


H2. Frontend OIDC code exchange uses wrong endpoint

File: frontend/taskdeck-web/src/views/LoginView.vue

When the OIDC callback redirects back with oauth_code=..., the handleOAuthCode function calls session.exchangeOAuthCode() which hits /auth/github/exchange. The OIDC-specific session.exchangeOidcCode() (which hits /auth/oidc/exchange) is never called from any view.

This works accidentally because both endpoints share the same _authCodes ConcurrentDictionary, but:

  1. The user sees a toast saying "Signed in with GitHub" for OIDC logins
  2. Error messages reference GitHub ("GitHub sign-in failed")
  3. If the code stores are ever separated (e.g., for audit logging by provider), OIDC login will silently break

Fix: Detect whether the oauth_code came from an OIDC flow (e.g., via an additional query parameter like provider=oidc) and call exchangeOidcCode accordingly.


MEDIUM

M1. No per-user failed MFA attempt counter or account lockout

File: backend/src/Taskdeck.Application/Services/MfaService.cs

The MFA verify endpoint relies solely on the AuthPerIp rate limiter (20 requests/60s). A 6-digit TOTP code has 1M possibilities with 3 valid time windows, requiring ~333K attempts to brute-force. At 20/minute per IP this takes ~11.5 days from a single IP, but is trivially parallelized across a botnet.

There is no per-user failed attempt counter, no exponential backoff on failures, and no temporary account lockout after N failures. NIST SP 800-63B recommends rate limiting MFA verification to prevent brute-force attacks on the second factor.

Fix: Add a per-user failed attempt counter. After 5-10 consecutive failures, impose a progressive delay or temporary lockout (e.g., 15 minutes). Reset on successful verification.


M2. Recovery code entropy is low (32 bits)

File: backend/src/Taskdeck.Application/Services/MfaService.cs (GenerateRecoveryCodes)

Recovery codes are generated from 5 random bytes, hex-encoded, and truncated to 8 characters (format: XXXX-XXXX). This gives 32 bits of entropy per code. Industry standard (GitHub, Google, Microsoft) uses 10+ character alphanumeric codes with ~48-64 bits of entropy.

With 8 recovery codes at 32 bits each, and BCrypt verification being slow (preventing brute force on individual codes), this is mitigated but still below common standards.

Fix: Use RandomNumberGenerator.GetBytes(8) or more, and produce 10-12 character alphanumeric codes.


M3. No provider name sanitization in OIDC configuration

File: backend/src/Taskdeck.Application/Services/OidcProviderSettings.cs

The OidcProviderConfig.Name property accepts any string from configuration without validation. While this is admin-controlled config (not user input), a name like ../admin would produce callback paths like /api/auth/oidc/../admin/oauth-redirect and scheme names like Oidc_../admin. ASP.NET Core routing may normalize this safely, but it could cause unexpected behavior.

Fix: Add a regex validation in IsConfigured requiring ^[a-zA-Z0-9_-]+$ for the Name property.


LOW

L1. Static _usedCodes dictionary grows unboundedly under load

File: backend/src/Taskdeck.Application/Services/MfaService.cs

The _usedCodes ConcurrentDictionary for TOTP replay protection is cleaned up lazily in CleanupExpiredUsedCodes(), which is only called after each successful validation. Under sustained attack (many failed attempts), no cleanup runs, and the dictionary grows indefinitely. This is a potential memory pressure vector (though not a direct exploit).

Fix: Add a periodic cleanup or size cap, or cleanup on both successful and failed attempts.


L2. MfaSetup.vue exposes raw TOTP secret without QR code rendering

File: frontend/taskdeck-web/src/components/MfaSetup.vue

The setup component displays the raw Base32 secret in a <code> block and mentions a QR code but never actually renders one (no QR library, no <img> tag for the qrCodeUri). The qrCodeUri is available in the API response but unused. This degrades the user experience and increases the risk of users mistyping the secret.


L3. _authCodes not partitioned between GitHub and OIDC

File: backend/src/Taskdeck.Api/Controllers/AuthController.cs

The same _authCodes static dictionary is used for both GitHub OAuth and OIDC codes. While functionally safe (codes are cryptographically random and single-use), this means the GitHub exchange endpoint can redeem OIDC codes and vice versa. This prevents clean audit logging by provider and violates the principle of least privilege.


INFO

I1. OIDC token validation relies on ASP.NET Core defaults (adequate)

The AddOpenIdConnect middleware defaults are secure: ID token signature verification, issuer/audience validation, nonce validation, and state parameter CSRF protection are all enabled by default. No explicit TokenValidationParameters override is needed.

I2. Open redirect protection is correctly double-validated

The returnUrl parameter is checked with Url.IsLocalUrl() in both OidcLogin (entry) and OidcCallback (exit). ASP.NET Core IsLocalUrl correctly rejects protocol-relative URLs.

I3. Email collision prevention is correctly implemented

The ExternalLoginAsync flow creates a new user with a synthetic email when an email collision is detected, consistent with the existing GitHub OAuth security posture. Cross-provider identity isolation tests confirm this behavior.

I4. Recovery codes are correctly hashed with BCrypt at rest

Recovery codes are BCrypt-hashed before storage and verified using BCrypt.Net.BCrypt.Verify. Single-use enforcement works by removing the matching hash from the comma-separated list.


Summary

  • 2 CRITICAL: Plaintext TOTP secret storage, MFA enforcement is dead code
  • 2 HIGH: Recovery code lockout path, wrong frontend exchange endpoint
  • 3 MEDIUM: No per-user MFA brute-force protection, low recovery code entropy, unsanitized provider names
  • 3 LOW: Unbounded replay cache, no QR rendering, unpartitioned code store
  • 4 INFO: Positive findings on OIDC defaults, redirect protection, email collision, recovery hashing

The CRITICAL findings mean this PR should not be merged as-is. The MFA feature gives users a false sense of security: secrets are stored in plaintext, and the MFA policy for sensitive actions is entirely unconnected. Both issues are fixable with targeted changes.

The entity comment claimed TOTP secrets are encrypted at rest by the
infrastructure layer, but no encryption is actually applied. Update
the comment to accurately reflect the current plaintext storage and
document the need for a future encryption enhancement.
IsMfaRequiredForSensitiveActionAsync was dead code -- never called from
any controller. Now the change-password endpoint checks MFA policy and
requires a valid TOTP/recovery code when RequireMfaForSensitiveActions
is true and the user has MFA enabled. Adds MfaService dependency to
AuthController and optional MfaCode field to ChangePasswordRequest.
DisableAsync only validated TOTP codes, meaning users who lost their
authenticator app could not disable MFA even with valid recovery codes.
Now falls back to TryUseRecoveryCode when TOTP validation fails,
matching the pattern already used in VerifyCodeAsync.
OIDC callbacks now include oauth_provider=oidc in the redirect URL.
The frontend reads this parameter and routes to the correct exchange
endpoint (exchangeOidcCode vs exchangeOAuthCode), fixing incorrect
toast messages and error strings for OIDC logins.
AuthController now requires MfaService in its constructor. Add
CreateMockMfaService helper and update all test call sites.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Security Review Fixes Applied

Following the adversarial review, the following CRITICAL and HIGH findings have been fixed and pushed to this branch:

Fixed: C1 -- Misleading encryption-at-rest documentation

Commit: 9412b220 -- Corrected the MfaCredential entity comment that falsely claimed TOTP secrets are encrypted at rest. The comment now accurately documents the plaintext storage and flags the need for a future Data Protection enhancement. Full encryption was not applied in this fix because it requires infrastructure-layer changes (EF Core value converter + key management) that should be a separate, focused PR.

Fixed: C2 -- MFA enforcement wired into ChangePassword endpoint

Commit: 7673fb64 -- IsMfaRequiredForSensitiveActionAsync was dead code, never called from any controller. The ChangePassword endpoint now:

  1. Checks if MFA is required for the current user via policy
  2. Returns 403 with a clear message if MFA code is missing
  3. Validates the provided MFA code before proceeding with password change
  4. Added MfaService as a dependency to AuthController
  5. Added optional MfaCode field to ChangePasswordRequest

Fixed: H1 -- Recovery code support in DisableAsync

Commit: 3ddbd75d -- DisableAsync now falls back to TryUseRecoveryCode when TOTP validation fails, matching the pattern in VerifyCodeAsync. Users who lose their authenticator app can now disable MFA using recovery codes instead of being permanently locked out.

Fixed: H2 -- Frontend OIDC code exchange routing

Commit: 4408ef4f -- The OIDC callback now includes oauth_provider=oidc in the redirect URL. The frontend LoginView reads this parameter to route to the correct exchange endpoint (exchangeOidcCode vs exchangeOAuthCode), fixing incorrect "Signed in with GitHub" toasts and "GitHub sign-in failed" error messages for OIDC logins.

Fixed: Test updates

Commit: 9635c396 -- Updated AuthControllerEdgeCaseTests to pass the new MfaService constructor parameter to AuthController.

Remaining findings (MEDIUM/LOW, not fixed in this push)

  • M1: Per-user MFA brute-force lockout -- recommend as a follow-up issue
  • M2: Recovery code entropy (32 bits) -- recommend increasing to 48+ bits in a follow-up
  • M3: Provider name sanitization -- recommend regex validation in a follow-up
  • L1-L3: Low-priority items documented in the review comment
  • C1 full fix: Actual TOTP secret encryption at rest requires a separate PR with Data Protection infrastructure

All 5 commits build cleanly (dotnet build -c Release: 0 errors) and all existing tests pass (MfaService: 21/21, AuthController: 13/13, MfaCredential domain: 8/8, frontend typecheck: clean).

Chris0Jeky added a commit that referenced this pull request Apr 9, 2026
…e (PRs #806-#813)

STATUS.md: add Feature, Security, and Ops Expansion Wave section with
full delivery notes for 8 PRs; update backend controller list, auth
posture (OIDC/MFA/PKCE), frontend views (calendar/agents/storybook/
import/telemetry); update Phase 4 remaining items.

IMPLEMENTATION_MASTERPLAN.md: add delivery history entry #21 with wave
summary; update test baseline counts to ~5,600+.

TESTING_GUIDE.md: update verified totals to backend ~3,600+, frontend
~1,984+, combined ~5,600+; add Feature/Security Expansion Wave Testing
section documenting ~231+ new tests across 8 PRs.

decisions/INDEX.md: add ADR-0028 (staged deployment) and ADR-0029
(OIDC/MFA pluggable identity).
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9112435e4b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +128 to +132
authBuilder.AddOpenIdConnect(schemeName, provider.DisplayName, options =>
{
options.Authority = provider.Authority;
options.ClientId = provider.ClientId;
options.ClientSecret = provider.ClientSecret;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Configure OIDC with a sign-in cookie scheme

This OIDC registration never sets a SignInScheme or adds a cookie handler, while the app default scheme is JWT bearer. In the /oauth-redirect callback flow, the OpenID Connect handler needs a sign-in handler to persist the external principal before redirecting to OidcCallback; with bearer-only auth that callback path fails, so configured OIDC providers can't complete login. Register a cookie scheme for external auth and point OIDC SignInScheme to it.

Useful? React with 👍 / 👎.

Comment on lines +221 to +225
var replayKey = $"{userId.Value}:{code}:{step}";
if (_usedCodes.ContainsKey(replayKey))
return false;

// Mark code as used with expiry = 2 * tolerance * timeStep
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make TOTP replay protection atomic

Replay protection is implemented as a ContainsKey check followed by a later write, which is not atomic. Two concurrent requests using the same user/code/time-step can both observe the key as absent and both succeed, allowing one TOTP code to be replayed under parallel load. Use an atomic insert (for example, TryAdd) for the replay key check-and-set.

Useful? React with 👍 / 👎.

Chris0Jeky added a commit that referenced this pull request Apr 9, 2026
Update MANUAL_TEST_CHECKLIST last-updated date and add a new "Z. Outstanding PR Test Backlog" section (2026-04-10). The added backlog aggregates manual test tasks pulled from PR test plans (open PRs #797#813 and closed PRs #768#799), includes a status legend, and enumerates verification checklists across categories (Z1–Z17) such as OIDC/SSO & MFA, OAuth PKCE, calendar/timeline views, note import/web clip intake, Storybook, deployment/ops, testing infrastructure (Testcontainers, cross-browser E2E, visual regression, mutation testing), PWA/offline, SignalR scale-out, Postgres migration compatibility, forecasting, inbox UX, CSV export, accessibility, E2E edge scenarios, dependency hygiene, and concurrency stress tests. This provides a single reference for outstanding manual verification items tied to the referenced PRs.
# Conflicts:
#	backend/src/Taskdeck.Api/Taskdeck.Api.csproj
#	docs/decisions/INDEX.md
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e50b0825ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +133 to +136
var mfaResult = await _mfaService.VerifyCodeAsync(callerUserId, request.MfaCode);
if (!mfaResult.IsSuccess)
return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse(
ErrorCodes.AuthenticationFailed, "Invalid MFA verification code"));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate password before consuming recovery MFA codes

When MFA is required, this block verifies the MFA code before attempting the password change. VerifyCodeAsync consumes and persists recovery-code usage, so if the current password is wrong (or new password validation fails), the request is rejected but a one-time recovery code has already been burned. In practice, users can lose backup codes through failed password-change attempts; destructive MFA consumption should happen only after password validation succeeds (or within one transaction that rolls back on failure).

Useful? React with 👍 / 👎.

Comment on lines +88 to +89
<div v-if="status && !status.isSetupAvailable" class="td-mfa-setup__info">
MFA setup is not available on this instance. Contact your administrator.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep MFA disable controls visible when setup is disabled

This condition hides the entire enabled-state UI whenever isSetupAvailable is false. GetStatus can return isEnabled=true and isSetupAvailable=false (for example, after an admin turns setup off), and in that state users only see the “not available” message and cannot reach the disable flow. The branch should account for enrolled users (e.g., only show this message when setup is unavailable and MFA is not already enabled).

Useful? React with 👍 / 👎.

Chris0Jeky added a commit that referenced this pull request Apr 12, 2026
- MfaService: Store null instead of "EXHAUSTED" sentinel when recovery codes are exhausted
- MfaCredential.SetRecoveryCodes: Allow null/empty to clear codes (was throwing on empty)
- MfaCredential.Revoke(): Fix doc comment - clarify secret is preserved for audit trail
- AuthenticationRegistration: Add SignInScheme cookie for OAuth/OIDC external auth flow
- LoginView: Move type import before executable statements
- MfaSetup: Fix misleading "scan QR code" copy when no QR is rendered
- MfaService: Store null instead of "EXHAUSTED" sentinel when recovery codes are exhausted
- MfaCredential.SetRecoveryCodes: Allow null/empty to clear codes (was throwing on empty)
- MfaCredential.Revoke(): Fix doc comment - clarify secret is preserved for audit trail
- AuthenticationRegistration: Add SignInScheme cookie for OAuth/OIDC external auth flow
- LoginView: Move type import before executable statements
- MfaSetup: Fix misleading "scan QR code" copy when no QR is rendered
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94cc46a04c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +183 to +186
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
placeholder="000000"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow recovery codes in MFA disable input

Recovery codes are generated in XXXX-XXXX format, but this input is constrained to a 6-character numeric OTP, so backup codes cannot be entered here. In the common failure scenario where a user loses their authenticator app, they cannot use recovery codes to disable MFA from the UI, defeating the recovery path.

Useful? React with 👍 / 👎.

Comment on lines +338 to +339
if (string.IsNullOrWhiteSpace(username))
username = $"{provider.Name.ToLowerInvariant()}-user-{providerUserId}";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize OIDC fallback usernames before external login

This fallback uses the raw OIDC subject (providerUserId) to build a local username without any length guard. User enforces a 50-character maximum username, and ExternalLoginAsync passes this value through to user creation, so providers that emit long subject identifiers will fail login with validation errors instead of creating/signing in the user.

Useful? React with 👍 / 👎.

Backend: The MfaCredentialTests.SetRecoveryCodes_ShouldThrow_WhenEmpty test
expected a DomainException to be thrown when calling SetRecoveryCodes with an
empty string, but the implementation deliberately allows empty strings to clear
recovery codes (e.g., when exhausted). Updated test to verify the clearing behavior.

Frontend: The MfaSetup.spec.ts test expected the component to display "Add this
shared secret" but the component renders "Add this secret". Updated test to
match the actual component text.
# Conflicts:
#	backend/src/Taskdeck.Api/Controllers/AuthController.cs
#	backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs
#	backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs
#	frontend/taskdeck-web/src/api/authApi.ts
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Chris0Jeky added a commit that referenced this pull request Apr 12, 2026
Update SetRecoveryCodes test to match domain method: empty/null now
clears codes instead of throwing (supports exhausted recovery codes).
Remove AuthenticationRegistrationTests (belongs to PR #813, not cache PR).
Take main's versions for non-cache files.
Merge both OIDC settings and telemetry settings parameters in
SettingsRegistration and Program.cs. Both feature sets coexist.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky Chris0Jeky merged commit 3413e80 into main Apr 12, 2026
26 checks passed
@Chris0Jeky Chris0Jeky deleted the feature/sso-oidc-mfa-policy branch April 12, 2026 02:04
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Apr 12, 2026
Chris0Jeky added a commit that referenced this pull request Apr 12, 2026
Take main's versions for all non-cache files now that #813 provides
OIDC/MFA infrastructure. Keep MfaCredentialTests fix with both
empty-string and null clearing test cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

SEC-07: SSO/OIDC integration with optional MFA policy

2 participants